<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>btmp分析</title>
    <meta name="referrer" content="never">
    <!---->
    <script src="https://fastly.jsdelivr.net/npm/vue@2.7.14"></script>
    <!--<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>-->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/echarts/5.4.3/echarts.min.js"></script>
    <style>
      body {
        padding: 10px;
      }

      table {
        border-spacing: 0;
        margin: 10px;
        /*font-size: 5px;*/
      }

      table button {
        padding-top: 0;
        padding-bottom: 2px;
        /*font-size: 1px;*/
      }

      button {
        background-color: #71baf6;
        align-self: center;
        color: white;
        border-radius: 4px;
        border-width: 0;
        cursor: pointer;
        text-align: center;
        padding-top: 5px;
        padding-bottom: 5px;
      }

      th, td {
        border: 1px solid #d0dff2;
        white-space: nowrap;
        padding-left: 16px;
        padding-right: 16px;
      }

      .filterLabel {
        white-space: nowrap;
        width: 60px;
        text-align: right;
        margin: 5px;
      }

      .filterInput {
        width: 100%;
        margin: 5px;
      }

      .codeView {
        margin: 0;
        background-color: #f6f8fa;
        padding: 16px;
      }
    </style>
</head>
<body>
<!--html-->
<div id="app">
    <fieldset>
        <legend>概况</legend>
        <span>请选择<strong @click="codeView.show=!codeView.show" style="cursor: pointer; color: blue">[btmp.txt]</strong>文件:</span>
        <input id="selectedFile" type="file"/>
        <span v-text="fileInfo"></span>
        <!--codeView提示信息-->
        <pre class="codeView" v-if="codeView.show" v-html="codeView.text"></pre>
        <!---->
        <div v-if="loading" v-text="'处理中...'"></div>
        <div v-if="errMsg" v-text="errMsg"></div>
        <!--图表-->
        <div id="summaryChart" style="height: 230px;"></div>
    </fieldset>

    <!--表格-->
    <div style="display: flex; align-items: start">
        <!--用户名-->
        <table style="width: 300px;">
            <thead>
            <tr>
                <th>序号</th>
                <th>
                    <span v-text="'用户名 '+summaryName.keyCount+'个'"></span>
                    <select v-model="summaryName.topCount">
                        <option v-for="option in topCountOption" :value="option" :key="option" v-text="'top-'+option"></option>
                    </select>
                </th>
                <th>
                    <span>次数</span>
                    <button v-text="summaryName.topSort"
                            @click="summaryName.topSort=(summaryName.topSort==='↓'?'↑':'↓')"></button>
                </th>
            </tr>
            </thead>
            <tr v-for="(item, index) in summaryName.topData">
                <td v-text="index+1"></td>
                <td style="width: 80%;">
                    <button @click="changeFilter('name', item.keyValue, true, $event)">＋</button>
                    <button @click="changeFilter('name', item.keyValue, false, $event)">－</button>
                    <span v-text="item.keyValue"></span>
                </td>
                <td v-text="item.count"></td>
            </tr>
        </table>
        <!--ip-->
        <table style="width: 600px;">
            <thead>
            <tr>
                <th>序号</th>
                <th>
                    <span v-text="'ip '+summaryIp.keyCount+'个'"></span>
                    <select v-model="summaryIp.topCount">
                        <option v-for="option in topCountOption" :value="option" :key="option" v-text="'top-'+option"></option>
                    </select>
                </th>
                <th>
                    <span>次数</span>
                    <button v-text="summaryIp.topSort"
                            @click="summaryIp.topSort=(summaryIp.topSort==='↓'?'↑':'↓')"></button>
                </th>
                <th>归属</th>
            </tr>
            </thead>
            <tr v-for="(item, index) in summaryIp.topData">
                <td v-text="index+1"></td>
                <td style="width: 45%;">
                    <button @click="changeFilter('ip', item.keyValue, true, $event)">＋</button>
                    <button @click="changeFilter('ip', item.keyValue, false, $event)">－</button>
                    <span v-text="item.keyValue"></span>
                </td>
                <td v-text="item.count"></td>
                <td style="width: 45%;">
                    <!-- 说明: a.href 执行js方法, 类似JSONP? 详见注释'whois'
                    传入: 要查询的ip, 用于显示查询结果的[元素id]
                    入参要用单引号括住
                      -->
                    <a class="queryIpLoc" :id="'i-'+item.keyValue"
                       :href="`javascript:labelIp('i-${item.keyValue}', '${item.keyValue}')`">点击查询</a>
                </td>
            </tr>
        </table>
    </div>

    <!--筛选栏-->
    <div style="display: flex">
        <span class="filterLabel">用户名</span>
        <button v-if="dataFilter.name" @click="dataFilter.name='';updateFilterData()">x</button>
        <input class="filterInput" v-model="dataFilter.name" @keyup.enter="updateFilterData()">
    </div>
    <div style="display: flex">
        <span class="filterLabel">ip</span>
        <button v-if="dataFilter.ip" @click="dataFilter.ip='';updateFilterData()">x</button>
        <input class="filterInput" v-model="dataFilter.ip" @keyup.enter="updateFilterData()">
    </div>
    <div style="display: flex">
        <span class="filterLabel">日期</span>
        <button v-if="dataFilter.date" @click="dataFilter.date='';updateFilterData()">x</button>
        <input class="filterInput" v-model="dataFilter.date" @keyup.enter="updateFilterData()">
    </div>
    <button style="min-width: 100px" @click="updateFilterData()" v-text="'过滤 '+ dataFilter.resCount"></button>
    <button style="min-width: 100px" @click="dataFilter.ip='';dataFilter.name='';dataFilter.date='';updateFilterData()"
            v-text="'重置'"></button>
    <span>表格: 点击[+][-]时,按下[ctrl键]: 不马上刷新页面</span>

    <!--图表-->
    <div id="dataFilterChart" style="height: 230px;"></div>
</div>
<!--js-->
<!--js-->
<!--一般工具类-->
<script>
  const storageUtil = {
    /**
     * 存储localStorage
     */
    setStore(key, data) {
      if (!key) return
      if (typeof data !== 'string') {
        data = JSON.stringify(data)
      }
      window.localStorage.setItem(key, data)
    },

    /**
     * 获取 字符串
     */
    getString(key, defValue) {
      if (!key) return defValue
      let res = window.localStorage.getItem(key)
      if (!res) return defValue
      return res
    },
    /**
     * 获取 [json, bool等]
     */
    getObject(key, defValue) {
      if (!key) return defValue
      let res = window.localStorage.getItem(key)
      if (!res) return defValue
      return JSON.parse(res)
    },

    /**
     * 删除localStorage
     */
    removeStore(key) {
      if (!key) return
      window.localStorage.removeItem(key)
    },
  }

  /**
   * @param date Date
   * @return {string} 'yyyy-MM-dd HH:mm:ss'
   */
  function formatTime(date) {
    let y = date.getFullYear()
    let m = (date.getMonth() + 1).toString().padStart(2, '0')
    let d = date.getDate().toString().padStart(2, '0')
    return `${y}-${m}-${d} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}:${date.getSeconds().toString().padStart(2, '0')}`
  }

  /**
   * @param date Date
   * @return {string} 'yyyy-MM-dd'
   */
  function formatDate(date) {
    let y = date.getFullYear()
    let m = (date.getMonth() + 1).toString().padStart(2, '0')
    let d = date.getDate().toString().padStart(2, '0')
    return `${y}-${m}-${d}`
  }
</script>
<!--
查询ip归属
接口提供方 https://whois.pconline.com.cn
代码 提取自 https://whois.pconline.com.cn/ip.js 但要修正 url地址等
原理: 是JSONP? 在html中创建script脚本,调用接口后,后台返回js脚本内容,然后修改innerHTML
问题
.结果会有些不准确...
.因为是纯html,不能直接调用api,会跨域
.是JSONP?? 有安全风险***
.使用JSONP的方式时, 服务器也会校验Referer, 所以需要设置 meta
 -->
<script>
  let whois = {
    root: 'https://whois.pconline.com.cn',
    version: 2.0,
    labelIp: function (id, ip) {
      let s = document.getElementsByTagName('head')[0].appendChild(document.createElement("script"))
      s.src = this.root + "/jsLabel.jsp?ip=" + ip + "&id=" + id
    }
  }

  function labelIp(id, ip) {
    whois.labelIp(id, ip)
  }
</script>
<!--业务代码-->
<!--业务代码-->
<script>
  new Vue({
    el: '#app',
    data() {
      return {
        loading: false,
        errMsg: null,
        //顶部 tooltips
        codeView: {
          show: false,
          text: '获取方式\n' +
            'cd 到测试目录\n' +
            'ls /var/log\n' +
            'utmpdump /var/log/btmp > btmp.txt\n' +
            'echo 行数= `wc -l btmp.txt`\n' +
            'head -n 10 btmp.txt\n' +
            'tail -n 10 btmp.txt\n' +
            '文件过大时,压缩\n' +
            'gzip -c btmp.txt>btmp.txt.gz\n\n' +
            '测试环境 处理25w条 基本不耗时 \n' +
            '详见笔记 <a href="https://gitee.com/maoTVReporter/note/tree/master/%E5%BC%80%E5%8F%91/%E7%8E%AF%E5%A2%83%E7%AD%89/linux/%E6%9C%8D%E5%8A%A1%E5%99%A8%E5%AE%89%E5%85%A8%E7%AD%89/%E7%AC%94%E8%AE%B0.md"' +
            ' target="_blank">https://gitee.com/maoTVReporter/note/tree/master/开发/环境等/linux/服务器安全等/笔记.md</a>',
        },
        //被选文件的信息
        fileInfo: '',
        // 全部数据 [{name, ip, time}]
        lstData: [],
        //概况图表
        summaryChart: null,
        // 分组统计
        topCountOption: [10, 20, 30, 50, 100, 200],
        topSortOption: ['↓', '↑'],
        summaryName: {
          // key总数
          keyCount: 0,
          // 前n个key
          topData: [],
          // 前n个
          topCount: storageUtil.getObject('summaryName.topCount', 10),
          topSort: '↓',
        },
        summaryIp: {
          keyCount: 0,
          topData: [],
          topCount: storageUtil.getObject('summaryIp.topCount', 10),
          topSort: '↓',
        },
        //
        dataFilter: {
          //lstData 的筛选条件
          date: storageUtil.getString('dataFilter.date', ''),
          name: storageUtil.getString('dataFilter.name', ''),
          ip: storageUtil.getString('dataFilter.ip', ''),
          //筛选后的结果
          resCount: 0,
          //lstData 的子集
          data: [],
          //图表
          chart: null,
        },
      }
    },
    watch: {
      'summaryName.topCount': function (newValue) {
        // console.log('summaryName.topCount', newValue, this.dataFilter)
        storageUtil.setStore('summaryName.topCount', newValue)
        this.summaryName = this.summaryKey(this.dataFilter.data, 'name', this.summaryName)
      },
      'summaryName.topSort': function (newValue) {
        this.summaryName = this.summaryKey(this.dataFilter.data, 'name', this.summaryName)
      },
      'summaryIp.topCount': function (newValue) {
        // console.log('summaryIp.topCount', newValue)
        storageUtil.setStore('summaryIp.topCount', newValue)
        this.summaryIp = this.summaryKey(this.dataFilter.data, 'ip', this.summaryIp)
      },
      'summaryIp.topSort': function (newValue) {
        this.summaryIp = this.summaryKey(this.dataFilter.data, 'ip', this.summaryIp)
      },
    },
    mounted() {
      console.log('mounted')
      //初始化图表
      this.summaryChart = echarts.init(document.getElementById("summaryChart"))
      this.summaryChart.getZr().on('click', info => this.onClickSummaryChart(info))
      this.dataFilter.chart = echarts.init(document.getElementById("dataFilterChart"))
      window.addEventListener('resize', () => {
        this.summaryChart.resize()
        this.dataFilter.chart.resize()
      })
      this.updateChart(this.summaryChart, this.lstData)
      this.updateChart(this.dataFilter.chart, this.dataFilter.data)

      //[选择文件]监听
      document.getElementById('selectedFile').addEventListener('change', event => {
        console.log('doReadFileBtmp', event.target.files[0])
        let file = event.target.files[0]
        this.fileInfo = ''
        if (!file) return

        let m1 = 1024 * 1024
        if (file.size > m1) {
          this.fileInfo = `${(file.size / m1).toFixed(1)} MB`
        } else {
          this.fileInfo = `${(file.size / 1024).toFixed(1)} KB`
        }

        this.loading = true
        let reader = new FileReader()
        reader.onload = event => {
          try {
            //读取文件
            this.lstData = this.parseBtmpFile(event.target.result)
            this.lstData.sort((a, b) => a.time.localeCompare(b.time))
            // console.log('lstData', JSON.stringify(this.lstData))

            //概况信息
            this.updateChart(this.summaryChart, this.lstData)
            //表格和图表
            this.updateFilterData()
            this.loading = false
          } catch (err) {
            console.error('处理异常', err)
            this.loading = false
            this.errMsg = '解析异常,详见控制台日志:' + err
          }
        }
        reader.readAsText(file)
      })
    },
    methods: {
      /**
       * 处理:筛选条件,数据展示,
       * @param lstData [{name, ip, time}]
       */
      updateFilterData() {
        let lstData = this.lstData
        console.log('行数.1=', lstData.length, this.dataFilter.date, this.dataFilter.name, this.dataFilter.ip)
        //条件筛选
        storageUtil.setStore('dataFilter.date', this.dataFilter.date)
        storageUtil.setStore('dataFilter.name', this.dataFilter.name)
        storageUtil.setStore('dataFilter.ip', this.dataFilter.ip)
        if (this.dataFilter.date) {
          let split = this.dataFilter.date.split(',')
          lstData = lstData.filter(item => {
            for (let key of split) {
              if (key && item.time.includes(key)) return true
            }
            return false
          })
        }
        if (this.dataFilter.name) {
          let split = this.dataFilter.name.split(',')
          lstData = lstData.filter(item => {
            for (let key of split) {
              if (key && item.name.includes(key)) return true
            }
            return false
          })
        }
        if (this.dataFilter.ip) {
          let split = this.dataFilter.ip.split(',')
          lstData = lstData.filter(item => {
            for (let key of split) {
              if (key && item.ip.includes(key)) return true
            }
            return false
          })
        }
        this.dataFilter.resCount = lstData.length
        this.dataFilter.data = lstData

        //表格
        this.summaryName = this.summaryKey(lstData, 'name', this.summaryName)
        this.summaryIp = this.summaryKey(lstData, 'ip', this.summaryIp)
        this.$nextTick(() => {
          Array.from(document.getElementsByClassName('queryIpLoc')).forEach(element => {
            element.innerHTML = '点击查询'
          })
        })
        //图表
        this.updateChart(this.dataFilter.chart, lstData)
      },
      /**
       * 处理list.item中的key:
       * .按key分组
       * .数量统计:key分组数量, 每个key的次数, 总次数
       * .选取数量最多的前n条记录
       * @return {keyCount, topData[{keyValue,count}], topCount}
       */
      summaryKey(list, key, {topCount, topSort}) {
        // console.log('summaryKey', key, topCount, topSort)
        let grouped = list.reduce((res, item) => {
          if (!res[item[key]]) res[item[key]] = 0
          res[item[key]]++
          return res
        }, {})
        // console.log('grouped', grouped, Object.keys(grouped).length)

        let res = {
          keyCount: Object.keys(grouped).length,
          topData: null,
          topCount,
          topSort,
        }

        let topData = Object.entries(grouped)
          .sort((a, b) => (topSort === '↓' ? 1 : -1) * (b[1] - a[1]))
          .slice(0, topCount)
        for (let i = topData.length - 1; i >= 0; i--) {
          let item = topData[i]
          topData[i] = {
            keyValue: item[0],
            count: item[1],
          }
        }
        res.topData = topData
        // console.log('res.' + key, res)
        return res
      },
      /**
       * 更新图表
       * @param lstData [{name, ip, time}]
       */
      updateChart(chart, lstData) {
        chart.showLoading()
        //按日期分组
        let grouped = lstData.reduce((res, item) => {
          //按日期分组
          let date = item.time.substring(0, 10)
          if (!res[date]) res[date] = 0
          res[date]++
          return res
        }, {})
        // console.log('grouped', grouped)

        //统计信息文本, title
        let realDay = Object.keys(grouped).length
        let realCount = lstData.length
        let title
        if (realCount) {
          let st = lstData[0].time
          let ed = lstData[realCount - 1].time
          let second = (new Date(ed).getTime() - new Date(st).getTime()) / 1000
          title = `${realCount}次/${realDay}天::   `
            + `${(realCount / Math.max(second / 60, 1)).toFixed(1)}次/分   `
            + `${(realCount / Math.max(second / 3600, 1)).toFixed(1)}次/时   `
            + `${(realCount / Math.max(second / 3600 / 24, 1)).toFixed(1)}次/天   `
        } else {
          title = '没有数据'
        }

        //构造chart数据, 补全缺失日期
        let oneDay = 24 * 3600 * 1000
        let dateSt = 'null'
        let dayDiff = 0
        if (lstData.length) {
          dateSt = new Date(lstData[0].time.substring(0, 10))
          let dateEd = new Date(lstData[lstData.length - 1].time.substring(0, 10))
          dayDiff = (dateEd - dateSt) / oneDay + 1
          // console.log('date', dateSt, dateEd, dayDiff)
        }
        let dates = []
        let values = []
        for (let i = 0; i < dayDiff; i++) {
          let now = new Date(dateSt.getTime() + i * oneDay)
          let dateStr = formatDate(now)
          dates.push(dateStr)
          values.push(grouped[dateStr] || 0)
        }
        // console.log('dates', dates)
        // console.log('values', values)

        chart.setOption({
          title: {
            left: 'left',
            text: title
          },
          tooltip: {
            trigger: 'axis',
            // position: function (pt) {
            //   return [pt[0], '10%']
            // }
          },
          dataZoom: [
            {
              type: 'inside',
              start: 0,
              end: 100
            },
            {
              start: 0,
              end: 100
            }
          ],
          grid: {
            left: '50',
            right: '10',
          },
          xAxis: {
            type: 'category',
            data: dates
          },
          yAxis: {
            type: 'value'
          },
          series: [
            {
              data: values,
              type: 'line'
            }
          ]
        })
        chart.hideLoading()
      },
      changeFilter(key, value, isAdd, event) {
        console.log('changeFilter', event.ctrlKey)
        let oldValue = this.dataFilter[key]
        value = ',' + value + ','
        if (isAdd) {
          if (!oldValue.includes(value)) this.dataFilter[key] += value
        } else {
          this.dataFilter[key] = oldValue.replaceAll(value, '')
        }
        //按下ctrl时, 不更新
        if (!event.ctrlKey) {
          this.updateFilterData()
        }
      },
      //
      onClickSummaryChart(info) {
        // console.log('click', info)
        if (!this.summaryChart.getOption()) return//未初始化
        let event = info.event
        let x = event.offsetX
        let y = event.offsetY
        let indexCoor = this.summaryChart.convertFromPixel({seriesIndex: 0}, [x, y])
        let date = this.summaryChart.getOption().xAxis[0].data[indexCoor[0]] || ''//点了图标外面时,会是undefined
        console.log('onClickSummaryChart', indexCoor, date)

        //按住ctrl
        if (!date) return
        let isDelete = this.dataFilter.date.includes(',' + date + ',')
        this.changeFilter('date', date, !isDelete, {})
      },
      //处理BtmpFile===========
      //处理BtmpFile===========
      /**
       * @return [{name, ip, time}]
       */
      parseBtmpFile(fileStr) {
        //逐行解析
        // console.log(fileStr)
        let lines = fileStr.split('\n')
        console.log('行数(大概)', lines.length)

        let res = []
        for (let line of lines) {
          if (!line) continue
          /*
          utmpdump /var/log/btmp > /var/log/btmp.txt
            导出的文本数据
          格式
          [6] [24431] [    ] [admin   ] [ssh:notty   ] [193.201.9.109       ] [193.201.9.109  ] [五 9月 01 05:32:59 2023   ]
           */
          let cols = line.split('[')
          let name = cols[4].replaceAll(']', '').trim()
          let ip = cols[7].replaceAll(']', '').trim()
          let time = this.parseTime(cols[8].replaceAll(']', '').trim())
          time = formatTime(time)
          // console.log('row=', name, ip, time, cols[8].replaceAll(']', '').trim())
          res.push({
            name, ip, time,
          })
        }
        return res
      },
      /**
       * @param str 五 9月 01 05:32:59 2023
       */
      parseTime(str) {
        if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(,\d{6})?\+\d{4}$/.test(str)) {
          //2022-10-02T05:25:34,000000+0000
          return new Date(str.replace(/,\d{6}\+\d{4}$/, '+0000'));
        }

        //五 9月 01 05:32:59 2023
        str = str.substring(2)//删除'周'
        //校验格式
        const regex = /(\d+)月 (\d+) (\d+):(\d+):(\d+) (\d{4})/
        const match = str.match(regex)
        if (!match) throw new Error(`时间格式异常: ${str}`)

        //res
        let year = Number(match[6])
        let month = Number(match[1]) - 1 // JavaScript的月份是从0开始的
        let day = Number(match[2])
        let hour = Number(match[3])
        let minute = Number(match[4])
        let second = Number(match[5])
        return new Date(year, month, day, hour, minute, second)
      }
    }
  })
</script>
</body>
</html>